[レポート] RustとTokioによる次世代ネットワークインフラ #reinvent #opn205
こんにちは。サービスグループの武田です。
re:Invent 2020のWave 2が開催中です。Next-gen networking infrastructure with Rust and Tokioのセッションを視聴しましたのでレポートします。
セッション概要
- スピーカー
- Carl Lerche(AWS Speaker)
- Sean McArthur(AWS Speaker)
- タイトル
- Next-gen networking infrastructure with Rust and Tokio
- OPN205
今日のネットワークインフラストラクチャソフトウェアには厳しい要件があります。 高速であるだけでなく、安全である必要があります。つまり、クラッシュしたり、セキュリティの悪用に対して脆弱になったりすることなく、信頼できないデータを処理できる必要があります。 従来、これら2つの要件は相反していました。 ネットワークプログラマーは、速度または安全性のいずれかを提供する言語を選択する必要がありました。 Rustプログラミング言語とTokioネットワーキングライブラリを使用すると、両方を使用できます。 このセッションでは、Tokioのゼロコストの抽象化を活用して、表現力、速度、安全性をトレードオフで提供するネットワーキングプラットフォームを提供する方法を示します。
どのプログラミング言語を採用するべきなのか
- TokioはRustプログラミングのための非同期ランタイム
- TokioとRustは速度に妥協することなく信頼性の高いネットワークアプリケーションを構築するためのすばらしいツールである
- RustとTokioはAmazonにとって比較的新しいものだが、特にデータプレーンサービスに関してはよく使われている
- これまで任意のコードを書く際にはJavaかGoを採用することが多かった
- GCのある言語だと実行を一時停止するためレイテンシーが発生する
- データベースのようなソフトウェアだと影響が大きい
- ではCやC++を採用すれば解決できるのか?
- PostgreSQLやMySQLのような古いプロジェクトはC/C++を採用し成功させてきた
- C/C++を採用する場合、メモリ管理はプログラマーの責務になる
- しかし人はミスをする。バグを埋め込むこともあるし、デバッグは酷く退屈なものだ
- ChromeやFirefoxのような大規模なC/C++ベースのプロジェクトでバグ履歴の分析をした
- 深刻度の高いバグのうち、平均で70%がメモリ管理のミスによることがわかっている
安全性 vs パフォーマンス
- 歴史的にプログラミング言語を選ぶ際には、安全性とパフォーマンスのトレードオフがあった
- データベースなどマルチテナントのソフトウェアでは安全性を最優先に考える必要がある
- C/C++で正しいコードを書くことは可能だが、より多くの知識と努力が必要
- 正しさを保証するために静的分析など追加の作業に投資しなければいけない
- そのため、安全面が組み込まれた言語を採用するのもまた正しい
- たとえ実行時のパフォーマンスのような追加コストが伴うとしても
- (スピーカーが)趣味のデータベースを開発する際にJavaを採用しようとしていたら、友人からRustを勧められた
- RustはC/C++に似たシステムレベルのプログラミングが可能で、かつランタイムやGCがない。それなのにメモリ安全である
- そのため前述したようなセキュリティ脆弱性はRustで防ぐことが可能
- Rustはコンパイル時にメモリ安全性が保証され、メモリの問題はコンパイル時に検出できる
- Rustでは安全性とパフォーマンスのトレードオフは存在しない。両立できる
- データの所有権と呼ばれる概念を使って、安全なメモリ管理(メモリアクセスおよび解放)を実現する。データの寿命は静的に追跡できる
- これは所有権の簡単な例
- 文字列を作って変数
foo
に代入している - 次に関数
take_ownership
に変数を渡しているが、同時に所有権も渡される - その結果、関数呼び出しの後に再度
foo
を使おうとするとコンパイルに失敗する - 関数
take_ownership
を見てみると、変数s
が文字列を所有するようになったが、明示的に文字列を解放するコードは存在しない - 関数が処理を返すと変数
s
はスコープ外になり、Rustコンパイラは変数s
が所有している文字列が参照できなくなったことを認識し、関連するリソースを自動的に解放する- この場合はメモリが解放される
- つまり、Rustを使うとメモリ管理を完全に制御できる
- プログラマーはヒープやスタックのメモリ割り当てを覚えておく必要はなく、自動的に解放され、バグを防ぐことができる
高速、信頼性、生産性のいいとこ取り
- (当時)データベースをRustで書こうとしたが問題があった。周りにエコシステムがなかった
- 基本的なブロッキングTCPソケットAPIしか提供していなかった
- ネットワークスタックを書くことから始め、信頼性の高いTokioをリリースした
- Tokioを使用したHello worldの例
- インバウンドのTCP接続とループプロセス、ループでは新しい接続をリッスンし各接続について新しいタスクをスポーンさせ、
hello world
を書き出す- タスクとは非同期のグリーンスレッドのようなもの(goroutineやプロセスの概念)で並列実行を可能にする
- オーバーヘッドがほとんどないため、アプリケーションは数百、数千のタスクを生成できる
listener
が返したsocket
を明示的に閉じていないが、スコープ外になるタイミングで自動的に解放される- うっかり閉じ忘れなどを防ぎ、さまざまなバグを防止できる
Tokioは信頼性の高いネットワーキングアプリケーションを書くために必要なものをすべて提供している。
- TCP, UDP, UNIX sockets, pipes
- Timers
- Channels, mutex, semaphore
- Subprocesses
- Signal handlers
- Multithreaded, work-stealing, task scheduler
このセッションでは、Tokioの非同期処理について掘り下げていく。新しい概念ではないが、Rustでは実装がユニークだ。
- ちょっとした非同期のコードだが、同期的に見える方法で実装している
- このコードはOSのスレッドをブロックすることはない
- またスリープの呼び出しは10msの待ち時間を必要とするが、待機中にスレッドをブロックしない
- Rustではコンパイラがこれらの呼び出しをすべて見つけ、スレッドに実行を返すように変換し、スレッドがほかを呼び出せるようにする
- 先ほどのコードがどのように変換されるかを簡単に確認してみよう
- コンパイラは非同期タスクをステートマシンに変換する
- タスクを実行するとき、match(あるいはif/else)で現在の状態を見て、その状態のコードを実行する
- タスクはInit状態で開始され、
sleep
を実行しても完了しないため、待ち状態となる - その後Tokioによって再びタスクが呼び出され、スリープ状態から再開し、タスクが完了する
- ここでのポイントは、ステートマシンを使用していることと、ステートマシンは非常に軽い構造体であること
- これらによって(コーディングおよび実行時の)パフォーマンスを損なうことなく非同期処理を実現できる
- Tokioランタイムはイベントが発生するのを待ち、非同期タスクはアイドル状態になっている
- また複数のイベントソースを持っている
- ワーカーは準備ができたタスクとプロセスの実行キューを持っている
- 先ほどの例では、10ms経過するとタイマーがオフになり、Tokioは関連するタスクを見つけワーカーにプッシュする
- タスクをプッシュするとポーリング関数が呼び出させれる
- ステートマシンが起動したときに実行される関数
- タスクは完了するか、ワーカーに戻り再びアイドル状態となり別のイベントを待つ
- Tokioランタイムは複数のワーカーをサポートしている
- 複数のスレッドにまたがるタスクのバランスを効率的に取れるワークスティーリングスケジューラが付属している
- そのため特別なことをしなくても、多くのコアを活用できる
コワクナイ並行性
- Rustの所有権モデルはメモリ管理だけでなくデータ競合を完全に防げる
- データ競合はスレッドがデータを読み込んでいるときに、別のスレッドが予期せずに同じデータを操作したときに起きる
- つまり完全に防げる
- 先ほどの例を、コンパイル時にメッセージを返すのではなく、サーバー起動時に指定されたメッセージを受け取れるように変更してみる
msg
は文字列で、実行ファイルへの引数として渡される- このプログラムにはバグがある。データは1つしかないのに複数のスレッドで処理される
- メインタスクが先に完了し他のタスクが動いている間に、メモリを解放しようとする可能性がある
- Rustではこのプログラムはコンパイルできない
- ループ内でメッセージをタスクに移動させているが、最初の繰り返し以降、もはや所有していないので不可能
- 解決策は?ひとつの方法は、各タスクにデータのコピーを移動させること
- ソケット受け入れ後、メッセージの新しいコピーをタスクに移動させる
- Rustは満足するだろう
- この方法の欠点は、メッセージが大きい場合オーバーヘッドも大きくなる可能性がある
- 解決する他の方法はSeanに任せよう
mini-redis ツアー
- Carlが話していた概念を開発者が利用できるようにするためmini-redisを作った
- mini-redisはRedisの書き直しのため、最初にRedisの紹介をする
- インメモリデータベース
- 共有キャッシュとして使われる
- 多くの同時接続をサポートする
- Cで書かれている
- mini-redisはRedisのサブセット
- RustおよびTokioで書かれている
- 教育目的であり代替ではない
- 最初の仕事はアプリケーションのエントリポイントを作ること
- 先ほどの例とよく似ている
- 注目すべきはデータベースの部分で、接続のたびにコマンドを適用する
struct Db(HashMap<String, Bytes>);
- ハッシュマップの定義から始める
- 文字列キーを持っており、値はバイト
- ところで、所有権を覚えているだろうか?複数のスレッドをまたいでタスクを共有したいが、Rustの所有権ではできなかった
- そこで何かを追加して可能にしなければいけない
struct Db(Arc<HashMap<String, Bytes>>);
- HashMapを包んだのは参照カウントされたスマートポインタ
- クローンを作ると数が増え、解放されると数が減る。カウントがなくなると全体がクリーンアップされる
- これで共有はできるようになるが、変更ができない。さらに何か追加しなければいけない
struct Db(Arc<Mutex<HashMap<String, Bytes>>>);
- HashMapを操作できるようMutexを追加した
- これで異なるタスクが同じデータベースを操作できるようになった
- 標準のMutexは標準ライブラリにあり、ロックする状態を型システム自体にエンコードする
- ロックは内部のデータを保護するもので、ロックを取得するまで内部のデータは触れない
- 万が一アンロックを忘れたとしても、コンパイラがキャッチして教えてくれる
- 先ほどのDBに
get
を実装するコードを見てみよう - DBはMutexで保護された参照カウントなハッシュマップだったので、まずはロックを取得する
- このロックは排他的
- ロックが取得できたら単なるハッシュマップのため、キーを探すだけ
- この関数が終了すると、
locked
はスコープ外となり、デストラクタによって自動的にMutexのロックが解除される
- 反対に
set
の実装だが、あらためて考える必要はない get
と同様にロックを取得し、値を挿入するだけ- ロックは排他的なので安全
- 関数が終了すれば、デストラクタによってロックは解除される
- 最後にパフォーマンスを確認してみよう
- Redisに付属しているベンチマークツールを実行してみた
- ほとんど同じだ
- ベンチマークの負荷を上げても問題はないはずだ
- Tokioランタイムを使ってマルチスレッド化し、Rustによって安全でないコードはどこにもない
まとめ
現代のプログラミング言語では安全性とパフォーマンスのどちらかを選ぶ必要はない。借用チェッカーと強力な型システムがコンパイル時の安全性を保ち、余分なコストをかけずに済むからだ。信頼性が高く、スケーラブルなネットワークアプリケーションを作る必要があるときには、Rustを使うといいだろう。
このセッションの次はどうすればいいか?tokio.rsをチェックしてほしい。またチュートリアルなど、学習のための多くのリソースがある。
感想
社内でもRustの機運は高まってきています。またTokioもバージョン1がリリースされ、今とてもホットなライブラリです。所有権など学習曲線の険しさがよく話題に上がりますが、だからこそ早めに手をつけることでステップアップが期待できます。ぜひ皆さんもRustを始めましょう!
AWS re:Invent 2020は現在絶賛開催中です!
参加がまだの方は、この機会にぜひこちらのリンクからレジストレーションして豊富なコンテンツを楽しみましょう!
AWS re:Invent | Amazon Web Services
またクラスメソッドではポータルサイトで最新情報を発信中です!